💡 Google Search API 사용 안내:
• 최신 정보 검색으로 AI 글의 정확도 향상
• 하루 100회 무료 (초과 시 $5/1000회)
• 설정하지 않아도 기본 AI 포스팅 가능
설정 정보는 어디서 찾나요?
Google OAuth 클라이언트 ID: Google Cloud Console에서 `Blogger API` 활성화 후, '사용자 인증 정보' > '+ 만들기' > 'OAuth 클라이언트 ID' (유형: 웹 애플리케이션)를 선택하여 생성합니다. **'승인된 자바스크립트 원본'**에 이 앱을 실행할 URL(예: `http://localhost:8080`)을 반드시 추가해야 합니다.
Gemini API 키: Google AI Studio에서 발급받습니다. 텍스트 생성에만 사용되며, 이미지는 Pollinations.ai를 통해 무료로 생성됩니다.
최대 4개의 블로그 주소를 입력하고 RSS를 가져온 후, 블로그 개수에 상관없이 총 4개의 포스트를 선택하면 AI가 최적의 내부링크 포스팅을 생성합니다.
블로그별 최근 글 (총 4개 선택)
0/4 선택됨
최근 글 목록 (4개 선택)
0/4 선택됨
0자
AI가 4개 글을 분석하여 생성한 제목입니다. 수정 버튼을 눌러 직접 편집할 수 있습니다.
버튼 1:0/30
버튼 2:0/30
버튼 3:0/30
버튼 4:0/30
생성된 대표 제목으로 Google 검색을 수행하여 더욱 풍부한 내용을 작성합니다.
📌 내부링크 버튼 미리보기
예상 글자수: 약 10,000자
제목을 직접 입력하여 대량 포스팅을 예약합니다.
주제 선택 (0/0개)
예약 설정
📍 예약 기간 정보:
하루에 발행할 시간을 입력하세요. (예: 09:00, 17:00 = 하루 2회)
각 포스트 작성 사이의 대기 시간입니다. 서버 부하를 줄이기 위해 사용됩니다.
검색 기능 활성화됨:
• 각 포스팅마다 최신 자료를 검색합니다
• 검색 → 글 작성 → 포스팅 순서로 진행
• 남은 무료 검색: 100/100회
블로그 SEO를 위한 라벨/태그를 입력하세요 (각 라벨 최대 20자, 전체 최대 150자)
예약된 글 목록
Google에서 자료 검색 중...
준비 중...
2. 도입부
도입부 첫 문장으로 독자의 관심을 끌어요. 두 번째 문장에서 주제를 소개해요. 세 번째 문장으로 이어가요.
네 번째 문장에서 배경을 설명해요. 다섯 번째 문장으로 중요성을 강조해요.
3. 본문 섹션 구조 - 회색 박스 제목 + h3/h4 소제목 + 본문
[섹션 1-6은 모두 콘텐츠 관련 주제로 작성]
1. 첫 번째 주제 제목 (콘텐츠 관련)
1.1 세부 주제
내용 설명 첫 번째 문장이에요. 두 번째 문장으로 보충해요. 세 번째 문장으로 예시를 들어요.
추가 정보를 제공해요. 마무리 문장이에요.
[섹션 2-5도 동일한 구조로 콘텐츠 관련 내용 작성]
6. 여섯 번째 주제 제목 (콘텐츠 관련)
6.1 마지막 콘텐츠 주제
여섯 번째 섹션의 내용이에요. 이 섹션도 FAQ가 아닌 콘텐츠 관련 주제예요.
추가 설명을 이어가요. 중요한 정보를 담아요.
4. FAQ 섹션 - 7번째 섹션으로 독립
7. 자주 묻는 질문
Q1. 첫 번째 질문은 무엇인가요?
간결한 답변을 제공해요. 핵심 정보만 담아 설명해요.
Q2. 두 번째 질문은 무엇인가요?
실용적인 답변을 드려요. 구체적인 해결책을 제시해요.
Q3. 세 번째 질문은 무엇인가요?
명확한 답변을 제공해요. 이해하기 쉽게 설명해요.
Q4. 네 번째 질문은 무엇인가요?
도움이 되는 정보를 드려요. 추가 팁도 함께 제공해요.
Q5. 다섯 번째 질문은 무엇인가요?
자주 나오는 궁금증을 해결해요. 실제 사례를 들어 설명해요.
Q6. 여섯 번째 질문은 무엇인가요?
종합적인 답변을 제공해요. 전체 내용을 정리해드려요.
5. 면책조항과 요약
⚠️ 면책조항
이 글은 일반적인 정보 제공 목적으로 작성되었으며, 전문가의 조언을 대체할 수 없어요.
📌 요약
• 첫 번째 핵심 포인트
• 두 번째 핵심 포인트
• 세 번째 핵심 포인트
• 네 번째 핵심 포인트
• 다섯 번째 핵심 포인트
핵심 작성 규칙:
★ 섹션 구조 (필수 준수):
- 섹션 1-6: 모두 콘텐츠 관련 주제로만 작성
- 섹션 7: 자주 묻는 질문 (FAQ)만
- 절대로 섹션 6에 FAQ를 넣지 말 것
- 총 7개 섹션 (콘텐츠 6개 + FAQ 1개)
1. 구조:
- 섹션 제목: 연한 회색 박스(#f5f5f5) 안에 검은색 텍스트
- 모든 제목(h2, h3, h4): 검은색(#000000)
- 제목 내 링크도 검은색 유지
- 깔끔한 계층 구조 유지
2. 스타일:
- 모든 제목 검은색 통일
- 섹션 헤더만 회색 박스 배경
- 모든 본문 font-size: 16px
- 본문 내 링크만 파란색, 제목은 검은색
3. 줄바꿈:
- 2-3문장마다
사용
- 문단 간 적절한 간격 유지
- 가독성 중심으로 구성
4. 내용:
- "해요", "이에요" 어체 사용
- 중복 없이 논리적 흐름
- 실용적이고 구체적인 정보
- FAQ는 정확히 6개, 섹션 7에만
5. 금지사항:
- ** 또는 * 표시 금지
- 강조 박스 사용 금지
- 화려한 디자인 요소 금지
- h1 태그 사용 금지
- 섹션 6에 FAQ 넣기 금지
위 가이드라인을 정확히 따라 심플하고 읽기 쉬운 블로그 글을 작성해주세요.`;
// 수정된 이미지 생성 함수들 (사람/얼굴 제거 강화)
async function translateToEnglish(koreanText) {
if (!GEMINI_API_KEY) {
console.warn('Gemini API 키가 없어 원문 프롬프트로 이미지를 생성합니다.');
return koreanText;
}
try {
const translatePrompt = `
You are an image prompt cleaner and translator.
1) Translate the following Korean blog image description into natural English.
2) Focus on OBJECTS, PRODUCTS, INTERIORS, LANDSCAPES or FOOD ONLY.
3) NEVER include: person, people, man, woman, girl, boy, child, face, selfie, portrait, body, hand, arm, leg, skin, model, character, cartoon, anime, illustration.
4) Remove any words related to humans or body parts.
5) Write a single English sentence, no quotes.
Text:
"${koreanText}"
`;
const body = {
contents: [{
parts: [{ text: translatePrompt }]
}]
};
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}
);
if (!response.ok) {
console.error('번역 실패:', await response.text());
return koreanText;
}
const data = await response.json();
let english = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || koreanText;
// 혹시 남아 있을지 모를 사람 관련 단어 한 번 더 필터링
const peopleKeywords = [
'person','people','man','woman','boy','girl','child','face',
'selfie','portrait','body','human','hands','hand','arm','leg','skin',
'character','figure'
];
let cleaned = english;
for (const kw of peopleKeywords) {
const re = new RegExp(`\\b${kw}s?\\b`, 'gi');
cleaned = cleaned.replace(re, '');
}
cleaned = cleaned.replace(/\s+/g, ' ').trim();
if (!cleaned) {
cleaned = 'high quality product, detail shot, no people, studio background';
}
return cleaned;
} catch (error) {
console.error('translateToEnglish error:', error);
return koreanText;
}
}
async function generatePollinationsImage(prompt) {
try {
const hasKorean = /[ㄱ-ㅎ|ㅏ-ㅣ|가-힣]/.test(prompt);
let basePrompt = prompt;
if (hasKorean) {
basePrompt = await translateToEnglish(prompt);
}
// 사람 관련 단어 강제 제거
const peopleKeywords = [
'person','people','man','woman','boy','girl','child','face',
'selfie','portrait','body','human','hands','hand','arm','leg','skin',
'character','figure','model'
];
let cleaned = basePrompt;
for (const kw of peopleKeywords) {
const re = new RegExp(`\\b${kw}s?\\b`, 'gi');
cleaned = cleaned.replace(re, '');
}
cleaned = cleaned.replace(/\s+/g, ' ').trim();
if (cleaned.length < 10) {
cleaned += ' high quality product photo, no people, studio lighting';
}
const positiveEnhancements = [
'high quality photography',
'sharp focus',
'professional lighting',
'detailed texture',
'no people',
'no humans',
'clean background'
];
const negativePrompts = [
'person','people','man','woman','boy','girl','child','face',
'selfie','portrait','body','human','hands','hand','arms','legs','skin',
'cartoon','anime','illustration','drawing','3d render','cgi',
'low quality','blurry','deformed','disfigured'
];
const finalPrompt = `${cleaned}, ${positiveEnhancements.join(', ')}`;
const encodedPrompt = encodeURIComponent(finalPrompt);
const negative = encodeURIComponent(negativePrompts.join(', '));
const seed = Date.now() + Math.floor(Math.random() * 10000);
// flux + no_people=true 강제 적용
const imageUrl =
`https://image.pollinations.ai/prompt/${encodedPrompt}` +
`?model=flux&width=1024&height=768&nologo=true` +
`&seed=${seed}&safe=true&no_people=true&negative=${negative}`;
return imageUrl;
} catch (error) {
console.error('generatePollinationsImage error:', error);
const fallbackPrompt = encodeURIComponent(
'high quality product photo, modern interior, detailed texture, no people, no humans, studio lighting'
);
return `https://image.pollinations.ai/prompt/${fallbackPrompt}?model=flux&width=1024&height=768&nologo=true&safe=true&no_people=true`;
}
}
// 수정된 대량 예약 관련 함수들
async function handleGenerateTopics() {
const manualInput = document.getElementById('manual-topics-input');
const loader = document.getElementById('topic-ideas-loader');
const topicSelectionContainer = document.getElementById('topic-selection-container');
const bulkScheduleConfig = document.getElementById('bulk-schedule-config');
if (!manualInput) return;
const raw = manualInput.value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (raw.length === 0) {
showStatusMessage('한 줄에 하나씩 제목을 먼저 입력해주세요.', 'error');
manualInput.focus();
return;
}
loader.classList.remove('hidden');
topicSelectionContainer.classList.add('hidden');
bulkScheduleConfig.classList.add('hidden');
try {
// 중복 제거
const uniqueTopics = [...new Set(raw)];
renderTopicIdeas(uniqueTopics);
topicSelectionContainer.classList.remove('hidden');
bulkScheduleConfig.classList.remove('hidden');
showStatusMessage(`총 ${uniqueTopics.length}개의 제목이 준비되었습니다. 사용할 제목을 선택해주세요.`, 'success');
} catch (error) {
console.error(error);
showStatusMessage('제목 리스트를 불러오는 중 오류가 발생했습니다.', 'error');
} finally {
loader.classList.add('hidden');
}
}
function renderTopicIdeas(topics) {
const listEl = document.getElementById('topic-ideas-list');
const totalTopicCountEl = document.getElementById('total-topic-count');
if (totalTopicCountEl) {
totalTopicCountEl.textContent = topics.length;
}
if (!listEl) return;
listEl.innerHTML = topics.map((topic, index) => {
const safeId = `topic-${index}`;
const safeValue = topic.replace(/"/g, '"');
return `
`;
}).join('');
// 체크박스 이벤트 리스너 추가
listEl.querySelectorAll('.topic-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const listItem = this.closest('li');
if (this.checked) {
listItem.classList.add('selected');
} else {
listItem.classList.remove('selected');
}
updateSelectedTopicCount();
validateScheduleSettings();
updateBulkDateInfo();
updateSearchQuotaDisplay();
});
});
updateSelectedTopicCount();
lucide.createIcons();
}
// 나머지 모든 함수들은 원본 코드와 동일하게 유지
// (여기서부터는 원본 코드의 나머지 부분을 그대로 복사)
// 멀티 블로그 RSS 관련 함수들
window.addBlogInput = function() {
if (currentBlogCount >= 4) {
showStatusMessage('최대 4개의 블로그만 추가할 수 있습니다.', 'error');
return;
}
currentBlogCount++;
const container = document.getElementById('blog-url-inputs-container');
const inputGroup = document.createElement('div');
inputGroup.className = 'multi-blog-input-group';
inputGroup.innerHTML = `
`;
container.appendChild(inputGroup);
lucide.createIcons();
// 첫 번째 입력창의 삭제 버튼 표시
const firstRemoveBtn = container.querySelector('.multi-blog-input-group:first-child .remove-blog-url-btn');
if (firstRemoveBtn && currentBlogCount > 1) {
firstRemoveBtn.classList.remove('hidden');
}
// 4개가 되면 추가 버튼 숨기기
if (currentBlogCount >= 4) {
document.getElementById('add-blog-url-btn').style.display = 'none';
}
}
window.removeBlogInput = function(index) {
const container = document.getElementById('blog-url-inputs-container');
const inputGroups = container.querySelectorAll('.multi-blog-input-group');
if (inputGroups.length <= 1) {
showStatusMessage('최소 1개의 블로그는 유지해야 합니다.', 'error');
return;
}
// 해당 입력창 제거
inputGroups.forEach((group, i) => {
const input = group.querySelector('.multi-blog-input');
if (input && parseInt(input.dataset.blogIndex) === index) {
group.remove();
}
});
currentBlogCount--;
// 인덱스 재정렬
const newInputGroups = container.querySelectorAll('.multi-blog-input-group');
newInputGroups.forEach((group, i) => {
const input = group.querySelector('.multi-blog-input');
const removeBtn = group.querySelector('.remove-blog-url-btn');
if (input) {
input.dataset.blogIndex = i;
input.placeholder = `https://example${i + 1}.blogspot.com`;
}
if (removeBtn) {
removeBtn.setAttribute('onclick', `removeBlogInput(${i})`);
}
});
// 1개만 남았을 때 삭제 버튼 숨기기
if (newInputGroups.length === 1) {
const lastRemoveBtn = newInputGroups[0].querySelector('.remove-blog-url-btn');
if (lastRemoveBtn) {
lastRemoveBtn.classList.add('hidden');
}
}
// 추가 버튼 다시 표시
if (currentBlogCount < 4) {
document.getElementById('add-blog-url-btn').style.display = 'inline-flex';
}
}
// 블로그 URL 복사 함수
window.copyBlogUrl = function() {
const blogUrlText = document.getElementById('blog-url-text');
const copyBtn = document.getElementById('copy-url-btn');
if (blogUrlText && selectedBlogUrl) {
navigator.clipboard.writeText(selectedBlogUrl).then(() => {
const originalHtml = copyBtn.innerHTML;
copyBtn.innerHTML = '✓';
copyBtn.style.backgroundColor = 'rgba(34, 197, 94, 0.3)';
setTimeout(() => {
copyBtn.innerHTML = originalHtml;
copyBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
lucide.createIcons();
}, 2000);
showStatusMessage('블로그 주소가 클립보드에 복사되었습니다!', 'success');
}).catch(err => {
showStatusMessage('복사 실패: ' + err.message, 'error');
});
}
}
// 제목 및 버튼 편집 관련 함수들
function setupTitleAndButtonEditing() {
const editMainTitleBtn = document.getElementById('edit-main-title-btn');
const editButtonsBtn = document.getElementById('edit-buttons-btn');
const mainTitleInput = document.getElementById('generated-main-title');
const buttonEditContainer = document.getElementById('button-edit-container');
if (editMainTitleBtn) {
editMainTitleBtn.addEventListener('click', () => {
if (!isEditingMainTitle) {
mainTitleInput.readOnly = false;
mainTitleInput.classList.remove('bg-gray-100');
mainTitleInput.classList.add('bg-white');
mainTitleInput.focus();
editMainTitleBtn.innerHTML = ' 저장';
isEditingMainTitle = true;
updateMainTitleCharCount();
} else {
mainTitleInput.readOnly = true;
mainTitleInput.classList.add('bg-gray-100');
mainTitleInput.classList.remove('bg-white');
editMainTitleBtn.innerHTML = ' 수정';
isEditingMainTitle = false;
generatedMainTitle = mainTitleInput.value;
showStatusMessage('제목이 저장되었습니다.', 'success');
}
lucide.createIcons();
});
}
if (editButtonsBtn) {
editButtonsBtn.addEventListener('click', () => {
if (!isEditingButtons) {
buttonEditContainer.classList.remove('hidden');
editButtonsBtn.innerHTML = ' 저장';
isEditingButtons = true;
// 현재 버튼 텍스트를 입력 필드에 표시
generatedButtonTexts.forEach((text, idx) => {
const input = document.getElementById(`button-text-${idx + 1}`);
if (input) {
input.value = text;
updateButtonCharCount(idx + 1);
}
});
} else {
buttonEditContainer.classList.add('hidden');
editButtonsBtn.innerHTML = ' 수정';
isEditingButtons = false;
// 수정된 버튼 텍스트 저장
for (let i = 1; i <= 4; i++) {
const input = document.getElementById(`button-text-${i}`);
if (input && generatedButtonTexts[i - 1]) {
generatedButtonTexts[i - 1] = input.value;
}
}
// 미리보기 업데이트
updateButtonPreview();
showStatusMessage('버튼 텍스트가 저장되었습니다.', 'success');
}
lucide.createIcons();
});
}
// 제목 입력 시 글자수 업데이트
if (mainTitleInput) {
mainTitleInput.addEventListener('input', updateMainTitleCharCount);
}
// 버튼 텍스트 입력 시 글자수 업데이트
for (let i = 1; i <= 4; i++) {
const input = document.getElementById(`button-text-${i}`);
if (input) {
input.addEventListener('input', () => updateButtonCharCount(i));
}
}
}
function updateMainTitleCharCount() {
const mainTitleInput = document.getElementById('generated-main-title');
const charCountEl = document.getElementById('main-title-char-count');
if (mainTitleInput && charCountEl) {
const length = mainTitleInput.value.length;
charCountEl.textContent = `${length}자`;
if (length === 0) {
charCountEl.className = 'char-count-display warning';
} else if (length > 50) {
charCountEl.className = 'char-count-display warning';
} else {
charCountEl.className = 'char-count-display success';
}
}
}
function updateButtonCharCount(buttonNum) {
const input = document.getElementById(`button-text-${buttonNum}`);
const charCountEl = input?.parentElement?.querySelector('.char-count-display');
if (input && charCountEl) {
const length = input.value.length;
charCountEl.textContent = `${length}/30`;
if (length === 0 || length > 25) {
charCountEl.className = 'char-count-display text-xs warning';
} else {
charCountEl.className = 'char-count-display text-xs';
}
}
}
function updateButtonPreview() {
const previewButtons = document.getElementById('preview-buttons');
if (!previewButtons || selectedRssPosts.length === 0) return;
previewButtons.innerHTML = generatedButtonTexts.map((text, idx) => `
${text}
`).join('');
}
// 멀티 블로그 RSS 가져오기
async function handleFetchMultiRss() {
const blogInputs = document.querySelectorAll('.multi-blog-input');
const blogUrls = [];
blogInputs.forEach(input => {
const url = normalizeUrl(input.value.trim());
if (url) {
blogUrls.push({ url, index: parseInt(input.dataset.blogIndex) });
}
});
if (blogUrls.length === 0) {
showStatusMessage('최소 1개 이상의 블로그 주소를 입력해주세요.', 'error');
return;
}
const fetchButton = document.getElementById('fetch-multi-rss-button');
fetchButton.disabled = true;
fetchButton.innerHTML = ' 로딩 중...';
multiBlogData = [];
multiBlogPosts = {};
selectedMultiBlogPosts = [];
totalSelectedPosts = 0;
try {
showStatusMessage(`${blogUrls.length}개 블로그에서 RSS 피드를 가져오는 중...`, 'info');
const fetchPromises = blogUrls.map(async (blogInfo) => {
const rssUrls = generateRssUrls(blogInfo.url);
let posts = [];
for (const rssUrl of rssUrls) {
for (const proxyUrl of corsProxies) {
try {
const rssData = await fetchRssWithProxy(rssUrl, proxyUrl);
posts = parseRss(rssData);
if (posts.length > 0) break;
} catch (error) {
continue;
}
}
if (posts.length > 0) break;
}
return {
url: blogInfo.url,
index: blogInfo.index,
posts: posts
};
});
const results = await Promise.all(fetchPromises);
let totalPosts = 0;
results.forEach(result => {
if (result.posts.length > 0) {
multiBlogData.push(result);
multiBlogPosts[result.index] = result.posts;
totalPosts += result.posts.length;
}
});
if (multiBlogData.length === 0) {
throw new Error('RSS 피드를 가져올 수 없습니다.');
}
showStatusMessage(`✅ ${multiBlogData.length}개 블로그에서 총 ${totalPosts}개의 포스트를 가져왔습니다!`, 'success');
displayMultiBlogPosts();
} catch (error) {
console.error('멀티 RSS 가져오기 실패:', error);
showStatusMessage('RSS 피드를 가져올 수 없습니다. 올바른 블로그 주소를 입력해주세요.', 'error');
} finally {
fetchButton.disabled = false;
fetchButton.innerHTML = ' 모든 블로그 RSS 가져오기';
lucide.createIcons();
}
}
// 멀티 블로그 포스트 표시
function displayMultiBlogPosts() {
const container = document.getElementById('multi-blog-rss-container');
const sectionsContainer = document.getElementById('multi-blog-sections');
container.classList.remove('hidden');
sectionsContainer.innerHTML = '';
multiBlogData.forEach((blogData, blogIndex) => {
const section = document.createElement('div');
section.className = 'blog-rss-section';
section.dataset.blogUrl = blogData.url;
section.dataset.blogIndex = blogData.index;
const headerHtml = `
⚠️ ${excess}개의 빈 슬롯이 발생합니다. ${exactDaysNeeded}일로 변경하세요.
`;
}
if (validationMessageEl) {
validationMessageEl.innerHTML = validationHtml;
lucide.createIcons();
}
}
function updateBulkDateInfo() {
const bulkStartDateInput = document.getElementById('bulk-start-date-input');
const bulkDateInfo = document.getElementById('bulk-date-info');
const bulkDateRange = document.getElementById('bulk-date-range');
const scheduleDaysInput = document.getElementById('schedule-days-input');
const scheduleTimesInput = document.getElementById('schedule-times-input');
const topicIdeasList = document.getElementById('topic-ideas-list');
if (!bulkStartDateInput || !bulkDateInfo || !bulkDateRange) return;
const startDate = bulkStartDateInput.value;
const days = parseInt(scheduleDaysInput?.value || 1, 10);
if (!startDate || isNaN(days)) {
bulkDateInfo.classList.add('hidden');
return;
}
const start = new Date(startDate);
const end = new Date(startDate);
end.setDate(end.getDate() + days - 1);
const times = scheduleTimesInput?.value.split(',').map(t => t.trim()).filter(Boolean) || [];
const selectedCount = topicIdeasList ? topicIdeasList.querySelectorAll('input[type="checkbox"]:checked').length : 0;
const postsPerDay = times.length;
const totalSlots = postsPerDay * days;
let infoHtml = `
• 시작: ${start.toLocaleDateString('ko-KR')}
• 종료: ${end.toLocaleDateString('ko-KR')}
• 기간: ${days}일
`;
if (postsPerDay > 0) {
infoHtml += `
• 하루 발행: ${postsPerDay}회 (${times.join(', ')})
• 총 슬롯: ${totalSlots}개
`;
if (selectedCount > 0) {
infoHtml += `• 선택한 주제: ${selectedCount}개 `;
if (totalSlots === selectedCount) {
infoHtml += `✅ 완벽 매칭`;
} else if (totalSlots < selectedCount) {
infoHtml += `❌ ${selectedCount - totalSlots}개 부족`;
} else {
infoHtml += `⚠️ ${totalSlots - selectedCount}개 초과`;
}
}
}
bulkDateRange.innerHTML = infoHtml;
bulkDateInfo.classList.remove('hidden');
}
function setDefaultDates() {
const today = new Date();
const bulkStartDateInput = document.getElementById('bulk-start-date-input');
if (bulkStartDateInput && !bulkStartDateInput.value) {
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
bulkStartDateInput.value = `${year}-${month}-${day}`;
}
}
async function fetchScheduledPostsWithTimes() {
try {
const data = await fetchWithAuth(
`https://www.googleapis.com/blogger/v3/blogs/${selectedBlogId}/posts?status=SCHEDULED&maxResults=500&fields=items(title,published)`
);
scheduledPostTitles.clear();
scheduledPostTimes.clear();
(data.items || []).forEach(post => {
scheduledPostTitles.add(post.title.toLowerCase());
const publishDate = new Date(post.published);
const dateKey = publishDate.toDateString();
const timeKey = `${String(publishDate.getHours()).padStart(2, '0')}:${String(publishDate.getMinutes()).padStart(2, '0')}`;
if (!scheduledPostTimes.has(dateKey)) {
scheduledPostTimes.set(dateKey, new Set());
}
scheduledPostTimes.get(dateKey).add(timeKey);
});
return { titles: scheduledPostTitles, times: scheduledPostTimes };
} catch (error) {
console.error('예약된 글 시간 정보 가져오기 실패:', error);
return { titles: new Set(), times: new Map() };
}
}
async function createSmartSchedule(topics, times, days, shouldGenerateImages, useGoogleSearch, startDateStr) {
const bulkQueue = [];
const skippedSlots = [];
const processedTopics = [];
await fetchScheduledPostsWithTimes();
const startDate = new Date(startDateStr);
let topicIndex = 0;
for (let dayOffset = 0; dayOffset < days && topicIndex < topics.length; dayOffset++) {
const currentDate = new Date(startDate);
currentDate.setDate(currentDate.getDate() + dayOffset);
const dateKey = currentDate.toDateString();
for (const timeStr of times) {
if (topicIndex >= topics.length) break;
const [hour, minute] = timeStr.split(':').map(Number);
const publishDate = new Date(currentDate);
publishDate.setHours(hour, minute, 0, 0);
const now = new Date();
if (publishDate <= now) {
skippedSlots.push({
date: currentDate.toLocaleDateString('ko-KR'),
time: timeStr,
reason: '과거 시간'
});
continue;
}
if (scheduledPostTimes.has(dateKey) && scheduledPostTimes.get(dateKey).has(timeStr)) {
skippedSlots.push({
date: currentDate.toLocaleDateString('ko-KR'),
time: timeStr,
reason: '기존 예약과 충돌'
});
continue;
}
const canUseSearch = useGoogleSearch && (searchUsageToday.count + bulkQueue.length < SEARCH_DAILY_LIMIT);
bulkQueue.push({
topic: topics[topicIndex],
publishDate: publishDate,
generateImage: shouldGenerateImages,
useGoogleSearch: canUseSearch
});
processedTopics.push(topics[topicIndex]);
topicIndex++;
}
}
return { bulkQueue, skippedSlots, processedTopics };
}
// RSS URL 정규화
function normalizeUrl(url) {
if (!url) return '';
url = url.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
return url.replace(/\/$/, '');
}
// RSS URL 생성
function generateRssUrls(blogUrl) {
const urls = [];
if (blogUrl.includes('blogspot.com')) {
urls.push(`${blogUrl}/feeds/posts/default`);
urls.push(`${blogUrl}/feeds/posts/default?alt=rss`);
} else if (blogUrl.includes('tistory.com')) {
urls.push(`${blogUrl}/rss`);
} else if (blogUrl.includes('wordpress.com')) {
urls.push(`${blogUrl}/feed/`);
} else if (blogUrl.includes('blog.naver.com')) {
const match = blogUrl.match(/blog\.naver\.com\/(.+)/);
if (match) {
urls.push(`https://rss.blog.naver.com/${match[1]}.xml`);
}
} else {
urls.push(`${blogUrl}/rss`);
urls.push(`${blogUrl}/feed`);
urls.push(`${blogUrl}/rss.xml`);
}
return urls;
}
// RSS 파싱
function parseRss(xmlText) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, 'text/xml');
if (doc.querySelector('parsererror')) {
throw new Error('XML 파싱 오류');
}
let items = doc.querySelectorAll('item');
let isAtom = false;
if (items.length === 0) {
items = doc.querySelectorAll('entry');
isAtom = true;
}
const posts = [];
items.forEach((item, index) => {
if (index >= 25) return;
let title, link, pubDate;
if (isAtom) {
title = item.querySelector('title')?.textContent?.trim() || `포스트 ${index + 1}`;
const linkElement = item.querySelector('link[rel="alternate"]') || item.querySelector('link');
link = linkElement?.getAttribute('href') || linkElement?.textContent || '';
pubDate = item.querySelector('published')?.textContent || item.querySelector('updated')?.textContent || '';
} else {
title = item.querySelector('title')?.textContent?.trim() || `포스트 ${index + 1}`;
link = item.querySelector('link')?.textContent?.trim() || '';
pubDate = item.querySelector('pubDate')?.textContent || '';
}
if (link && title) {
title = title.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"');
posts.push({ title, link, pubDate });
}
});
return posts;
} catch (error) {
console.error('RSS 파싱 오류:', error);
return [];
}
}
// 프록시를 통한 RSS 가져오기
async function fetchRssWithProxy(rssUrl, proxyUrl) {
try {
let fetchUrl;
if (proxyUrl.includes('allorigins.win')) {
fetchUrl = proxyUrl + encodeURIComponent(rssUrl);
} else {
fetchUrl = proxyUrl + rssUrl;
}
const response = await fetch(fetchUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
let data;
if (proxyUrl.includes('allorigins.win')) {
const json = await response.json();
data = json.contents;
} else {
data = await response.text();
}
if (data.includes(' 0) {
posts = parsedPosts;
success = true;
showStatusMessage(`✅ ${posts.length}개의 포스트를 성공적으로 가져왔습니다!`, 'success');
break;
}
} catch (error) {
console.error('RSS 가져오기 실패:', error);
continue;
}
}
if (success) break;
}
if (success) {
displayRssPosts(posts);
document.getElementById('rss-posts-container').classList.remove('hidden');
} else {
throw new Error('RSS 피드를 가져올 수 없습니다.');
}
} catch (error) {
console.error('RSS 가져오기 실패:', error);
showStatusMessage('RSS 피드를 가져올 수 없습니다. 올바른 블로그 주소를 입력해주세요.', 'error');
} finally {
fetchButton.disabled = false;
fetchButton.innerHTML = ' RSS 가져오기';
lucide.createIcons();
}
}
// RSS 포스트 표시
function displayRssPosts(posts) {
const container = document.getElementById('rss-posts-list');
container.innerHTML = '';
selectedRssPosts = [];
posts.forEach((post, index) => {
const dateStr = post.pubDate ? new Date(post.pubDate).toLocaleDateString('ko-KR') : '';
const postDiv = document.createElement('div');
postDiv.className = 'rss-post-item';
postDiv.innerHTML = `
`;
const checkbox = postDiv.querySelector('input[type="checkbox"]');
checkbox.addEventListener('change', (e) => {
if (e.target.checked) {
if (selectedRssPosts.length >= 4) {
e.target.checked = false;
showStatusMessage('최대 4개까지만 선택할 수 있습니다.', 'warn');
return;
}
selectedRssPosts.push({ title: post.title, link: post.link });
postDiv.classList.add('selected');
} else {
selectedRssPosts = selectedRssPosts.filter(p => p.link !== post.link);
postDiv.classList.remove('selected');
}
updateSelectedPostsCount();
if (selectedRssPosts.length === 4) {
generateMainTitle();
generateButtonTexts();
document.getElementById('internal-link-config').classList.remove('hidden');
document.getElementById('internal-char-count-info').classList.remove('hidden');
setupTitleAndButtonEditing();
} else {
document.getElementById('internal-link-config').classList.add('hidden');
document.getElementById('internal-link-preview').classList.add('hidden');
document.getElementById('internal-char-count-info').classList.add('hidden');
}
});
container.appendChild(postDiv);
});
}
// 선택된 포스트 수 업데이트
function updateSelectedPostsCount() {
const countElement = document.getElementById('selected-posts-count');
if (countElement) {
countElement.textContent = `${selectedRssPosts.length}/4 선택됨`;
countElement.className = selectedRssPosts.length === 4 ? 'text-sm text-green-600 font-bold' : 'text-sm text-gray-600';
}
}
// 버튼 텍스트 생성
async function generateButtonTexts() {
const previewContainer = document.getElementById('internal-link-preview');
const previewButtons = document.getElementById('preview-buttons');
previewContainer.classList.remove('hidden');
previewButtons.innerHTML = '
🤖 AI가 클릭하고 싶은 버튼 텍스트를 생성 중...
';
try {
const buttonPrompt = `
다음 4개의 블로그 글 제목을 분석하여, 각각에 대한 매력적이고 클릭하고 싶은 버튼 텍스트를 생성해주세요.
[원본 글 제목]
1. ${selectedRssPosts[0].title}
2. ${selectedRssPosts[1].title}
3. ${selectedRssPosts[2].title}
4. ${selectedRssPosts[3].title}
[버튼 텍스트 생성 규칙]
1. 각 버튼은 20자 이내로 구체적이고 명확하게
2. "자세히 보기", "더보기" 같은 일반적인 표현 금지
3. 글의 핵심 가치나 혜택을 직접적으로 표현
4. 호기심을 자극하는 구체적인 내용 포함
5. 이모지 1개씩 포함 (글 내용과 관련된 것)
6. 원본 제목의 핵심 키워드는 유지하되 더 매력적으로
7. 클릭하면 어떤 정보를 얻을 수 있는지 명확히 표현
다음 형식으로 4개의 버튼 텍스트만 제공 (설명 없이):
버튼1: [텍스트]
버튼2: [텍스트]
버튼3: [텍스트]
버튼4: [텍스트]
`;
const response = await geminiFetch('gemini-2.5-flash', buttonPrompt);
const lines = response.trim().split('\n');
generatedButtonTexts = lines.map((line, idx) => {
const match = line.match(/버튼\d+:\s*(.+)/);
if (match) {
return match[1].trim();
}
// 매칭 실패 시 원본 제목 활용
const title = selectedRssPosts[idx].title;
const emojis = ['🔥', '💡', '⭐', '✨'];
// 제목이 너무 길면 핵심 부분만 추출
const shortTitle = title.length > 15 ? title.substring(0, 15) + '...' : title;
return `${emojis[idx]} ${shortTitle}`;
});
// 미리보기 업데이트
previewButtons.innerHTML = generatedButtonTexts.map((text, idx) => `
${text}
`).join('');
showStatusMessage('✨ 클릭하고 싶은 버튼 텍스트가 생성되었습니다!', 'success');
} catch (error) {
console.error('버튼 텍스트 생성 실패:', error);
// 실패 시 원본 제목 기반 버튼 생성
const emojis = ['🔥', '💡', '⭐', '✨'];
generatedButtonTexts = selectedRssPosts.map((post, idx) => {
const title = post.title;
const shortTitle = title.length > 15 ? title.substring(0, 15) : title;
return `${emojis[idx]} ${shortTitle} 확인하기`;
});
previewButtons.innerHTML = generatedButtonTexts.map((text, idx) => `
${text}
`).join('');
}
}
// 메인 제목 자동 생성
async function generateMainTitle() {
const generatedTitleInput = document.getElementById('generated-main-title');
generatedTitleInput.value = '🤖 AI가 4개 글을 깊이 분석하여 매력적인 제목을 생성 중...';
try {
const titlePrompt = `
다음 4개의 블로그 글 제목을 깊이 분석하여, 이들을 포괄하는 매력적이고 구체적인 대표 제목을 생성해주세요.
[분석할 4개 글 제목]
1. ${selectedRssPosts[0].title}
2. ${selectedRssPosts[1].title}
3. ${selectedRssPosts[2].title}
4. ${selectedRssPosts[3].title}
[제목 생성 기준]
1. 4개 글의 공통 주제와 핵심 가치를 명확히 파악
2. 독자가 얻을 수 있는 구체적인 혜택이나 정보를 포함
3. SEO에 최적화된 핵심 키워드 포함
4. 클릭률을 높이는 숫자나 구체적인 내용 포함
5. 30자 이내로 명확하고 구체적으로
6. 막연한 표현 금지 (예: 완벽 가이드, 모든 것 등)
7. 독자가 클릭했을 때 무엇을 얻을지 명확히 표현
[금지 단어 - 절대 사용 금지]
- "1등", "1위", "최고", "최상", "베스트", "추천", "TOP", "BEST"
- "모든 것", "완벽", "완전", "전부"
- 과장된 표현이나 순위를 나타내는 단어 금지
- 객관적이고 사실적인 표현만 사용
완전히 새로운 창의적인 제목 1개만 제공 (따옴표, 설명 없이):
`;
const response = await geminiFetch('gemini-2.5-flash', titlePrompt);
let generatedTitle = response.trim().replace(/^["']|["']$/g, '');
// 약관 위반 단어 필터링
const prohibitedWords = ['1등', '1위', '최고', '최상', '베스트', '추천', 'BEST', 'TOP', 'No.1', '모든 것', '완벽', '완전', '전부'];
prohibitedWords.forEach(word => {
const regex = new RegExp(word, 'gi');
generatedTitle = generatedTitle.replace(regex, '');
});
// 제목 정리
generatedTitle = generatedTitle.replace(/\s+/g, ' ').trim();
// 제목이 너무 짧거나 비어있으면 기본 제목 생성
if (generatedTitle.length < 5) {
const keywords = selectedRssPosts.map(p => {
const words = p.title.split(' ');
return words[0] || '';
}).filter(Boolean);
generatedTitle = `${keywords[0]} 관련 핵심 정보 ${keywords.length}가지`;
}
generatedMainTitle = generatedTitle;
generatedTitleInput.value = generatedMainTitle;
updateMainTitleCharCount();
showStatusMessage('✨ AI가 매력적인 제목을 생성했습니다!', 'success');
} catch (error) {
console.error('제목 생성 실패:', error);
// 실패 시 기본 제목 생성
const firstTitle = selectedRssPosts[0].title;
const keywords = firstTitle.split(' ').slice(0, 3).join(' ');
generatedMainTitle = `${keywords} 핵심 정보 4가지`;
generatedTitleInput.value = generatedMainTitle;
updateMainTitleCharCount();
}
}
// RSS 내부링크 포스팅 생성
async function handleGenerateInternalLink() {
if (selectedRssPosts.length !== 4) {
showStatusMessage('정확히 4개의 글을 선택해주세요.', 'error');
return;
}
const publishTime = document.getElementById('internal-publish-time-input').value;
const useGoogleSearch = document.getElementById('use-google-search-internal').checked;
const shouldGenerateImage = document.getElementById('generate-image-checkbox-internal').checked;
if (!generatedMainTitle) {
showStatusMessage('메인 제목이 생성되지 않았습니다.', 'error');
return;
}
isProcessRunning = true;
initLog();
document.getElementById('progress-container').classList.remove('hidden');
globalStartTime = Date.now();
try {
addLog('🔗 RSS 내부링크 포스팅 생성 시작', 'info');
addLog(`📌 대표 제목: ${generatedMainTitle}`, 'info');
addLog(`📝 선택된 글: ${selectedRssPosts.length}개`, 'info');
addLog(`🖼️ 이미지 생성: ${shouldGenerateImage ? '예' : '아니오'}`, 'info');
// Google Search 실행 (옵션)
let searchResults = null;
if (useGoogleSearch && GOOGLE_SEARCH_API_KEY && GOOGLE_SEARCH_CX) {
updateProgress(10, '🔍 Google에서 관련 자료 검색 중...');
searchResults = await searchGoogle(generatedMainTitle);
if (searchResults) {
addLog('✅ 검색 완료! 최신 정보를 반영하여 글을 작성합니다', 'success');
}
}
updateProgress(30, 'AI가 고품질 내부링크 포스팅을 작성 중...');
// 내부링크 버튼 HTML 생성
const internalLinkButtons = selectedRssPosts.map((post, idx) => {
const buttonText = generatedButtonTexts[idx] || `🔥 ${post.title.substring(0, 20)}`;
return `
`;
}).join('\n');
// 내부링크 프롬프트
let internalLinkPrompt = `
**주제:** ${generatedMainTitle}
**내부링크로 연결할 4개의 글:**
${selectedRssPosts.map((post, idx) => `${idx + 1}. ${post.title}`).join('\n')}
**버튼 텍스트:**
${generatedButtonTexts.map((text, idx) => `${idx + 1}. ${text}`).join('\n')}
${searchResults ? `
**참고할 최신 정보 (Google 검색 결과):**
${searchResults}
` : ''}
**특별 지침:**
1. 주제 "${generatedMainTitle}"를 중심으로 10,000자 이상의 고품질 글 작성
2. 4개의 내부링크 버튼을 본문의 자연스러운 위치에 배치
3. 버튼 배치 위치:
- 첫 번째 버튼: 도입부 이후 (전체 글의 20% 지점)
- 두 번째 버튼: 중간 섹션 (전체 글의 40% 지점)
- 세 번째 버튼: 후반부 섹션 (전체 글의 60% 지점)
- 네 번째 버튼: 결론 직전 (전체 글의 80% 지점)
4. 각 버튼 앞뒤로 해당 글과 연관된 설명 문단 작성 (버튼을 자연스럽게 소개)
5. 버튼 HTML은 다음과 같이 정확히 삽입:
${internalLinkButtons}
**일반 지침:**
${WRITING_INSTRUCTIONS || defaultInstructions}
`;
const articleContent = await geminiFetch('gemini-2.5-flash', internalLinkPrompt);
// 글자 수 계산
const textContent = articleContent.replace(/<[^>]*>/g, '');
const charCount = textContent.length;
addLog(`✅ 글 작성 완료! (총 ${charCount.toLocaleString()}자)`, 'success');
// 이미지 생성 (선택사항)
let finalContent = articleContent;
if (shouldGenerateImage) {
updateProgress(50, '🎨 AI 이미지 생성 중...');
// 이미지 프롬프트 생성
const imagePrompts = [generatedMainTitle];
const h2Regex = /
]*>(.*?)<\/h2>/gi;
const h2Matches = [...finalContent.matchAll(h2Regex)];
if (h2Matches.length >= 1) {
imagePrompts.push(h2Matches[Math.floor(h2Matches.length / 2)][1].trim());
}
const imageUrls = [];
for (let i = 0; i < imagePrompts.length; i++) {
const p = imagePrompts[i];
addLog(`🎨 이미지 ${i+1}/${imagePrompts.length} 생성 중: "${p.substring(0, 30)}..."`, 'info');
const imageUrl = await generatePollinationsImage(p);
imageUrls.push({ url: imageUrl, alt: p });
addLog(`✅ 이미지 ${i+1} 생성 완료!`, 'success');
}
// 이미지 삽입
addLog('🖼️ 본문에 이미지 삽입 중...', 'info');
let imageTags = imageUrls.map(img => {
const altText = img.alt.replace(/"/g, '"');
return `${altText}`;
});
if (imageTags.length > 0) {
const separator = '';
const paragraphs = finalContent.split(/(<\/p>)/);
const assembledParts = [];
for (let i = 0; i < paragraphs.length; i += 2) {
assembledParts.push(paragraphs[i] + (paragraphs[i + 1] || ''));
}
if (assembledParts.length > 0) {
const thumbnailTag = imageTags.shift();
assembledParts[0] = assembledParts[0] + `\n${thumbnailTag}\n${separator}`;
}
if (imageTags.length > 0 && assembledParts.length > 2) {
const secondImageTag = imageTags.shift();
const midPoint = Math.floor(assembledParts.length / 2);
assembledParts.splice(midPoint, 0, secondImageTag);
}
finalContent = assembledParts.join('');
}
addLog(`✅ ${imageUrls.length}개 이미지 삽입 완료`, 'success');
}
updateProgress(70, 'SEO 태그 생성 중...');
const tagsPrompt = `다음 주제와 관련된 SEO 최적화 태그를 5-7개 생성해주세요:
주제: ${generatedMainTitle}
관련 글: ${selectedRssPosts.map(p => p.title).join(', ')}
금지 단어: 추천, 1등, 1위, 최고, 베스트, TOP, BEST
결과는 콤마로 구분된 태그만 제공:`;
const tagsText = await geminiFetch('gemini-2.5-flash', tagsPrompt);
const labels = processLabels(tagsText.split(',').map(tag => tag.trim()).filter(Boolean));
updateProgress(90, '포스팅 발행 준비 중...');
const postData = {
title: generatedMainTitle,
content: finalContent,
labels: labels
};
await publishPost(postData, publishTime);
updateProgress(100, '✅ RSS 내부링크 포스팅 완료!');
// 총 소요 시간 계산
const totalElapsed = Math.floor((Date.now() - globalStartTime) / 1000);
addLog(`🎉 RSS 내부링크 포스팅 완료! (총 ${totalElapsed}초 소요)`, 'success');
// 초기화
selectedRssPosts = [];
selectedMultiBlogPosts = [];
totalSelectedPosts = 0;
generatedMainTitle = '';
generatedButtonTexts = [];
document.getElementById('generated-main-title').value = '';
document.getElementById('internal-publish-time-input').value = '';
document.getElementById('multi-blog-rss-container').classList.add('hidden');
document.getElementById('internal-link-config').classList.add('hidden');
document.getElementById('internal-char-count-info').classList.add('hidden');
document.getElementById('selected-posts-summary').classList.add('hidden');
// 멀티 블로그 입력 초기화
const blogInputs = document.querySelectorAll('.multi-blog-input');
blogInputs.forEach(input => {
input.value = '';
});
} catch (error) {
console.error('RSS 내부링크 포스팅 생성 실패:', error);
addLog(`❌ 오류: ${error.message}`, 'error');
showStatusMessage('RSS 내부링크 포스팅 생성에 실패했습니다.', 'error');
} finally {
isProcessRunning = false;
}
}
// 도메인 추출 함수
function extractDomain(url) {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace('www.', '');
} catch {
return url;
}
}
// 검색 쿼리 최적화 함수
function optimizeSearchQuery(topic) {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
let enhancedQuery = topic;
const monthKeywords = {
'1월': `${currentYear}년 1월`,
'2월': `${currentYear}년 2월`,
'3월': `${currentYear}년 3월`,
'4월': `${currentYear}년 4월`,
'5월': `${currentYear}년 5월`,
'6월': `${currentYear}년 6월`,
'7월': `${currentYear}년 7월`,
'8월': `${currentYear}년 8월`,
'9월': `${currentYear}년 9월`,
'10월': `${currentYear}년 10월`,
'11월': `${currentYear}년 11월`,
'12월': `${currentYear}년 12월`
};
const seasonKeywords = {
'봄': `${currentYear}년 봄 3월 4월 5월`,
'여름': `${currentYear}년 여름 6월 7월 8월`,
'가을': `${currentYear}년 가을 9월 10월 11월`,
'겨울': `${currentYear}년 겨울 12월 1월 2월`
};
if (topic.includes('축제') || topic.includes('행사') || topic.includes('이벤트')) {
for (const [month, dateStr] of Object.entries(monthKeywords)) {
if (topic.includes(month)) {
enhancedQuery = topic.replace(month, dateStr);
break;
}
}
for (const [season, dateStr] of Object.entries(seasonKeywords)) {
if (topic.includes(season)) {
enhancedQuery = topic + ` ${dateStr}`;
break;
}
}
if (enhancedQuery === topic && !topic.includes(String(currentYear))) {
enhancedQuery = `${topic} ${currentYear}년 일정 날짜`;
}
}
const needsLatestInfo = ['가격', '요금', '시간', '영업시간', '운영시간', '예약', '할인', '이벤트'];
if (needsLatestInfo.some(keyword => topic.includes(keyword))) {
enhancedQuery = `${enhancedQuery} ${currentYear}년 최신`;
}
const locationKeywords = ['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종', '경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주'];
const hasLocation = locationKeywords.some(loc => topic.includes(loc));
if (hasLocation && (topic.includes('맛집') || topic.includes('카페') || topic.includes('여행'))) {
enhancedQuery = `${enhancedQuery} 인기`;
}
addLog(`🔎 검색 쿼리 최적화: "${topic}" → "${enhancedQuery}"`, 'info');
return enhancedQuery;
}
// Google Search API 관련 함수들
function loadSearchUsage() {
const saved = localStorage.getItem('googleSearchUsage');
if (saved) {
searchUsageToday = JSON.parse(saved);
const today = new Date().toDateString();
if (searchUsageToday.date !== today) {
searchUsageToday = { date: today, count: 0 };
saveSearchUsage();
}
}
updateSearchQuotaDisplay();
}
function saveSearchUsage() {
localStorage.setItem('googleSearchUsage', JSON.stringify(searchUsageToday));
}
function updateSearchQuotaDisplay() {
if (GOOGLE_SEARCH_API_KEY && GOOGLE_SEARCH_CX) {
const searchApiStatus = document.getElementById('search-api-status');
if (searchApiStatus) {
searchApiStatus.classList.remove('hidden');
}
const remaining = SEARCH_DAILY_LIMIT - searchUsageToday.count;
const usagePercent = (searchUsageToday.count / SEARCH_DAILY_LIMIT) * 100;
const searchQuotaDisplay = document.getElementById('search-quota-display');
const searchUsageBar = document.getElementById('search-usage-bar');
if (searchQuotaDisplay) {
searchQuotaDisplay.textContent = `${remaining}/${SEARCH_DAILY_LIMIT}회`;
}
if (searchUsageBar) {
searchUsageBar.style.width = `${usagePercent}%`;
if (usagePercent >= 90) {
searchUsageBar.style.backgroundColor = '#ef4444';
} else if (usagePercent >= 70) {
searchUsageBar.style.backgroundColor = '#f59e0b';
} else {
searchUsageBar.style.backgroundColor = '#22c55e';
}
}
const bulkRemainingSearches = document.getElementById('bulk-remaining-searches');
if (bulkRemainingSearches) {
bulkRemainingSearches.textContent = remaining;
}
const useGoogleSearchBulk = document.getElementById('use-google-search-bulk');
if (useGoogleSearchBulk && useGoogleSearchBulk.checked) {
const topicIdeasList = document.getElementById('topic-ideas-list');
const selectedCount = topicIdeasList ? topicIdeasList.querySelectorAll('input[type="checkbox"]:checked').length : 0;
if (selectedCount > remaining) {
const bulkSearchQuotaWarning = document.getElementById('bulk-search-quota-warning');
const bulkSearchWarningText = document.getElementById('bulk-search-warning-text');
if (bulkSearchQuotaWarning) {
bulkSearchQuotaWarning.classList.remove('hidden');
}
if (bulkSearchWarningText) {
bulkSearchWarningText.textContent = `⚠️ 선택한 주제(${selectedCount}개)가 남은 검색 횟수(${remaining}회)를 초과합니다. 처음 ${remaining}개만 검색이 적용되고, 나머지는 검색 없이 진행됩니다.`;
}
} else {
const bulkSearchQuotaWarning = document.getElementById('bulk-search-quota-warning');
if (bulkSearchQuotaWarning) {
bulkSearchQuotaWarning.classList.add('hidden');
}
}
}
} else {
const searchApiStatus = document.getElementById('search-api-status');
if (searchApiStatus) {
searchApiStatus.classList.add('hidden');
}
}
}
function updateSearchStatus(status, text) {
const searchStatusIndicator = document.getElementById('search-status-indicator');
const searchStatusText = document.getElementById('search-status-text');
if (!searchStatusIndicator) return;
const statusDot = searchStatusIndicator.querySelector('.search-status-dot');
if (searchStatusText) {
searchStatusText.textContent = text;
}
switch (status) {
case 'active':
searchStatusIndicator.classList.add('search-status-active');
searchStatusIndicator.classList.remove('search-status-inactive');
if (statusDot) statusDot.style.color = '#22c55e';
break;
case 'inactive':
searchStatusIndicator.classList.remove('search-status-active');
searchStatusIndicator.classList.add('search-status-inactive');
if (statusDot) statusDot.style.color = '#ef4444';
break;
case 'searching':
if (statusDot) statusDot.style.color = '#3b82f6';
break;
}
}
// Google Search 함수
async function searchGoogle(query, jobIndex = null, totalJobs = null) {
if (!GOOGLE_SEARCH_API_KEY || !GOOGLE_SEARCH_CX) {
addLog('⚠️ Google Search API가 설정되지 않아 검색을 건너뜁니다', 'warn');
return null;
}
if (searchUsageToday.count >= SEARCH_DAILY_LIMIT) {
addLog('⚠️ 일일 검색 한도(100회)를 초과했습니다. 검색 없이 진행합니다.', 'warn');
return null;
}
try {
const optimizedQuery = optimizeSearchQuery(query);
const searchProgressIndicator = document.getElementById('search-progress-indicator');
const searchProgressText = document.getElementById('search-progress-text');
if (searchProgressIndicator) {
searchProgressIndicator.classList.remove('hidden');
if (searchProgressText) {
if (jobIndex && totalJobs) {
searchProgressText.textContent = `Google 검색 중... (${jobIndex}/${totalJobs}) "${optimizedQuery.substring(0, 30)}..."`;
} else {
searchProgressText.textContent = `Google에서 "${optimizedQuery.substring(0, 30)}..." 검색 중...`;
}
}
}
updateSearchStatus('searching', '검색 중');
const url = `https://www.googleapis.com/customsearch/v1?key=${GOOGLE_SEARCH_API_KEY}&cx=${GOOGLE_SEARCH_CX}&q=${encodeURIComponent(optimizedQuery)}&num=${SEARCH_RESULTS_COUNT}&hl=ko&lr=lang_ko`;
addLog(`🔍 Google 검색 시작: "${optimizedQuery}"`, 'info');
const response = await fetch(url);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || `검색 실패: ${response.status}`);
}
const data = await response.json();
searchUsageToday.count++;
saveSearchUsage();
updateSearchQuotaDisplay();
if (!data.items || data.items.length === 0) {
addLog('검색 결과가 없습니다', 'info');
return null;
}
searchedSources = data.items.map(item => ({
title: item.title,
url: item.link,
domain: extractDomain(item.link)
}));
const formattedResults = data.items.map((item, index) => {
const domain = extractDomain(item.link);
return `[검색 결과 ${index + 1}]
제목: ${item.title}
출처: ${domain}
URL: ${item.link}
내용: ${item.snippet}
---`;
}).join('\n');
addLog(`✅ ${data.items.length}개의 검색 결과를 찾았습니다 (사용: ${searchUsageToday.count}/${SEARCH_DAILY_LIMIT})`, 'success');
const domainList = searchedSources.map((source, idx) => `${idx+1}. ${source.domain}`).join(', ');
addLog(`📌 검색된 사이트: ${domainList}`, 'info');
const singleSearchResultPreview = document.getElementById('single-search-result-preview');
const searchSourcesContainer = document.getElementById('search-sources-container');
if (singleSearchResultPreview && !singleSearchResultPreview.classList.contains('hidden')) {
singleSearchResultPreview.innerHTML = `
🔍 검색 결과 미리보기 (상위 5개):
${data.items.slice(0, 5).map((item, index) => {
const domain = extractDomain(item.link);
return `